Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

15장. 메서드

14장에서 포인터를 익혔다. 이제 한 발 더 나가서, 특정 타입에 “동작” 을 묶어 두는 방법을 배운다.

함수와 메서드의 경계는 한 줄 차이지만, 이 한 줄이 Go 의 객체 지향 스타일을 만든다.

목표:

  • 메서드와 함수의 차이를 이해한다
  • 값 리시버와 포인터 리시버를 구분한다
  • 어느 쪽을 언제 쓸지 판단할 수 있다
  • 사용자 정의 타입에 메서드를 붙여 본다

15.1 메서드란

메서드는 “특정 타입에 묶여 있는 함수” 다.

13장에서 만든 Person 구조체를 다시 가져와 보자.

type Person struct {
    Name string
    Age  int
}

이름을 출력하는 동작이 필요하다고 하자. 함수로 만들면 이렇게 된다.

func Greet(p Person) {
    fmt.Println("안녕,", p.Name)
}

Greet(p)

메서드로 만들면 이렇게 된다.

func (p Person) Greet() {
    fmt.Println("안녕,", p.Name)
}

p.Greet()

호출이 Greet(p) 에서 p.Greet() 로 바뀌었다. “인사하는 동작은 Person 에 속한다” 는 의도가 문법에 그대로 드러난다.

메서드와 함수의 차이

항목함수메서드
정의 위치어디든타입에 묶임
호출 형태f(x)x.f()
첫 인자일반 매개변수리시버 (receiver)

본질적으로는 메서드도 함수다. 단지 “어떤 타입의 동작인지” 를 문법으로 표현한 것뿐이다.


15.2 메서드 정의

메서드 정의 문법은 다음과 같다.

func (리시버변수 리시버타입) 메서드이름(매개변수) 반환타입 {
    // 본문
}

func 와 메서드 이름 사이에 괄호로 묶인 한 덩어리가 추가됐다. 이걸 리시버(receiver) 라고 부른다.

간단한 예제

type Rectangle struct {
    Width, Height float64
}

// Rectangle 에 Area 메서드를 정의
func (r Rectangle) Area() float64 {
    return r.Width * r.Height
}

사용은 이렇게 한다.

r := Rectangle{Width: 3, Height: 4}
fmt.Println(r.Area()) // 12

리시버 이름 짓는 관례

리시버 변수 이름은 보통 짧게 짓는다.

  • 타입 이름의 첫 글자 한두 개
  • Rectangler
  • Personp
  • HttpServers 또는 srv

Go 코드에서 this, self 같은 이름은 거의 안 쓴다.

메서드 이름 규칙

함수 / 변수와 같은 규칙이 적용된다.

  • 대문자로 시작 → 패키지 바깥에 공개됨 (exported)
  • 소문자로 시작 → 패키지 안에서만 사용

공개 / 비공개 규칙은 20장에서 더 자세히 다룬다.


15.3 값 리시버 vs 포인터 리시버

리시버는 두 가지 형태가 있다.

func (r Rectangle)  Area() float64 { ... }   // 값 리시버
func (r *Rectangle) Scale(factor float64)   { ... }   // 포인터 리시버

* 가 하나 붙는 작은 차이다. 하지만 동작은 14장에서 본 값/포인터 차이 그대로다.

값 리시버는 복사본을 받는다

type Counter struct {
    count int
}

func (c Counter) Add() {
    c.count++   // 복사본을 1 증가
}

func main() {
    c := Counter{}
    c.Add()
    c.Add()
    c.Add()
    fmt.Println(c.count) // 0
}

c.Add() 가 호출될 때마다 Counter 가 통째로 복사된다. 복사본을 늘려 봤자 원본은 그대로다.

포인터 리시버는 원본을 받는다

func (c *Counter) Add() {
    c.count++   // 원본을 1 증가
}

func main() {
    c := Counter{}
    c.Add()
    c.Add()
    c.Add()
    fmt.Println(c.count) // 3
}

*Counter 로 받았기 때문에 c.count++ 는 진짜 원본에 작용한다.

호출 문법은 똑같다

값이든 포인터든 호출은 똑같이 점으로 한다.

c := Counter{}
c.Add()    // 포인터 리시버라도 (&c).Add() 처럼 안 써도 됨

Go 가 자동으로 주소를 잡아 준다. 포인터로 갖고 있어도 마찬가지다.

p := &Counter{}
p.Add()    // OK

호출 시점에 “값을 메서드로 보낼 수 있는가” 만 보면 된다. 변수가 있고 주소를 잡을 수 있다면 Go 컴파일러가 알아서 변환한다.

값 리시버와 포인터 리시버 한눈에

항목값 리시버 (r T)포인터 리시버 (r *T)
받는 것복사본원본의 주소
원본 수정불가능가능
큰 구조체 비용매번 복사주소만 전달
nil 가능성없음 (값)있음 (*T)

15.4 어느 쪽을 쓸지 판단하기

처음에는 헷갈리지만 규칙이 단순하다.

1. 메서드가 값을 수정해야 하면 포인터

func (c *Counter) Add() { c.count++ }

값 리시버로는 어차피 안 바뀐다. 초보가 가장 자주 만나는 함정이다.

2. 큰 구조체면 포인터

type Report struct {
    Lines [10000]string
}

func (r *Report) Summary() string { ... }

매 호출마다 만 줄짜리 배열을 복사하지 않으려면 포인터 리시버를 쓴다.

3. 작고 불변이면 값

type Point struct {
    X, Y int
}

func (p Point) Distance() float64 { ... }

Point 같이 두세 필드짜리 구조체는 복사 비용이 거의 없다. 값 리시버가 더 안전하다 (변경 의도가 없음을 코드가 말해 준다).

4. 한 타입의 메서드는 하나로 통일하라

이게 가장 중요한 관례다.

// 권장하지 않음 — 섞여 있다
func (p Person) String() string { ... }
func (p *Person) SetAge(a int)  { ... }

값과 포인터를 섞으면 사용하는 쪽에서 헷갈린다. 또 16장의 인터페이스를 만족할 때 메서드 셋(method set) 규칙이 까다로워진다.

어느 한 메서드가 수정해야 한다면 모든 메서드를 포인터 리시버로 통일하는 게 일반적이다.

결정 흐름

조건결정
값을 수정해야 함포인터
구조체가 큼포인터
작고 안 바뀜
같은 타입의 다른 메서드가 포인터포인터 (통일)

15.5 사용자 정의 타입에 메서드 붙이기

메서드는 구조체에만 붙는 게 아니다.

기본 타입에 별명 짓기

type Celsius float64

c := Celsius(36.5)

Celsiusfloat64 와 다른 타입이다. 값은 똑같이 들어가지만, 컴파일러가 구분해서 본다.

거기에 메서드 붙이기

type Celsius float64

func (c Celsius) ToFahrenheit() Celsius {
    return c*9/5 + 32
}

func main() {
    body := Celsius(36.5)
    fmt.Println(body.ToFahrenheit()) // 97.7
}

float64 위에 의미를 한 겹 입힌 셈이다. 온도 단위 변환 같은 동작이 자연스럽게 묶인다.

슬라이스에도 가능하다

type Names []string

func (n Names) Count() int {
    return len(n)
}

func main() {
    list := Names{"가", "나", "다"}
    fmt.Println(list.Count()) // 3
}

단, 같은 패키지 안에서만

Go 는 “남의 패키지의 타입에 내 패키지에서 메서드를 붙이는 행위” 를 금지한다.

// 금지! 컴파일 에러
func (s string) Shout() string {
    return strings.ToUpper(s) + "!"
}

string 은 내장 타입이라 내가 메서드를 새로 정의할 수 없다. 필요하면 새 타입을 정의해야 한다.

type Shoutable string

func (s Shoutable) Shout() string {
    return strings.ToUpper(string(s)) + "!"
}

이 규칙은 “남이 만든 타입의 동작을 내가 마음대로 바꾸지 못하게” 하는 안전장치다.


15.6 정리

이 장에서 살펴본 내용:

  • 메서드는 특정 타입에 묶인 함수다
  • 호출은 x.f() 형태로 한다
  • 리시버에는 값 리시버와 포인터 리시버가 있다
  • 원본을 바꿔야 하면 포인터 리시버
  • 큰 구조체도 포인터 리시버 (복사 비용)
  • 작고 불변이면 값 리시버
  • 한 타입의 메서드는 하나로 통일하는 게 관례
  • 사용자 정의 타입(type MyInt int)에도 메서드를 붙일 수 있다
  • 다른 패키지의 타입에 메서드를 붙이는 건 금지

메서드까지 익히면 “동작을 가진 타입” 을 만들 수 있게 된다.

다음 장에서는 한 단계 더 추상화한다. “이런 메서드를 가진 모든 타입” 을 하나로 묶어 다루는 도구, 인터페이스를 배운다. Go 가 자랑하는 다형성이 여기서 등장한다.